Skip to content

fix: experiments with large types for aggregated values - #4791

Open
comphead wants to merge 7 commits into
apache:mainfrom
comphead:group_offset
Open

comphead wants to merge 7 commits into
apache:mainfrom
comphead:group_offset

Conversation

@comphead

@comphead comphead commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #4718 .

Support LargeUtf8/LargeBinary group keys in HashAggregate to bypass the 2 GiB offset cap

Problem

CUBE / GROUPING SETS + COUNT(DISTINCT wide_string) workloads with high group-key cardinality trip DataFusion's per-task ByteGroupValueBuilder<i32> byte-buffer cap (i32::MAX = 2 147 483 647),
surfacing as:

org.apache.comet.CometNativeException: offset overflow, buffer size > 2147483647
  at datafusion physical-plan/.../multi_group_by/bytes.rs:202-206

The cap is per-column per-task on the group-key accumulator, hit when the cumulative bytes of one Utf8 column across all interned distinct group tuples exceed 2 GiB.

Fix

Add a new config spark.comet.exec.useLargeDataTypes (default true) that promotes Utf8/Binary group-by expressions to LargeUtf8/LargeBinary before the aggregate, routing DataFusion to
ByteGroupValueBuilder<i64> (i64 offsets, effectively unbounded buffer). The Large variant is preserved end-to-end through shuffle and mapped back to Spark StringType at the JVM boundary — no cast-back
projection, no lossy round-trip.

Changes

Rust

  • native/proto/src/proto/operator.proto — added HashAggregate.use_large_data_types and ShuffleWriter.use_large_data_types flags.
  • native/core/src/execution/planner.rs
    • promote_byte_group_key wraps each group-by expr in CastExpr(LargeUtf8|LargeBinary) when the flag is on.
    • align_shuffle_writer_input accepts the flag and promotes Utf8/Binary in expected_output_schema to their Large variants before invoking SchemaAlignExec, so no down-cast is ever inserted.
  • native/core/src/execution/columnar_to_row.rs — maybe_cast_to_schema_type passes LargeUtf8/LargeBinary through unchanged (the row encoder's TypedArray::LargeString/LargeBinary variants already
    handle both offset widths).
  • native/shuffle/src/schema_align.rs — new CastLargeStringToString/CastLargeBinaryToBinary actions with a byte-aware row-range splitter that rebuilds each chunk via StringBuilder/BinaryBuilder
    (arrow's cast_byte_container fails on sliced offsets, so we can't rely on cast_with_options alone).
  • native/spark-expr/src/conversion_funcs/{cast,string}.rs — extended is_datafusion_spark_compatible to whitelist all four offset-width conversions (Utf8↔LargeUtf8, Binary↔LargeBinary), safety-net for
    any DF adapter that still constructs spark_expr::Cast for these types.

Scala / Java

  • spark/.../CometConf.scala — added COMET_AGG_USE_LARGE_DATATYPES with explanatory doc.
  • spark/.../operators.scala — wires the flag into both HashAggregate.newBuilder() sites via CometConf.COMET_AGG_USE_LARGE_DATATYPES.get(aggregate.conf).
  • spark/.../CometNativeShuffleWriter.scala — wires the flag into ShuffleWriter.newBuilder().
  • spark/.../comet/util/Utils.scala — maps LargeUtf8 → StringType / LargeBinary → BinaryType; adds LargeVarCharVector / LargeVarBinaryVector to the FFI export whitelist.

Test coverage

CometAggregateSuite gains one test that runs the same CUBE(9) + COUNT(DISTINCT) shape twice:

  • useLargeDataTypes=false on 30K × 384B rows → asserts the "offset overflow" exception (existing behavior preserved).
  • useLargeDataTypes=true on 12K × 170B rows → checkSparkAnswerAndOperator validates row-by-row equality against the Comet-disabled Spark baseline and re-asserts CometHashAggregateExec presence.

Residual limits

LargeUtf8 → Utf8 casts elsewhere in the pipeline still have to fit a single arrow-Utf8 batch (i32::MAX bytes). SchemaAlignExec splits by byte budget to stay under it; for extreme per-batch bytes this
can still fail — mitigated by lowering datafusion.execution.batch_size for the aggregate subtree if needed.

flowchart TD
    Scan["CometNativeScan parquet<br/><b>Utf8</b> (i32 offsets, ≤ 2 GiB per batch)"]
    Filter["CometFilter · CometProject · CometExpand<br/><b>Utf8</b> passthrough"]

    subgraph Agg["CometHashAggregate <i>(useLargeDataTypes=true)</i>"]
        direction TB
        Cast["CastExpr(Utf8 → LargeUtf8)<br/><i>promote_byte_group_key</i><br/><b>widen offsets i32 → i64</b>"]
        AggCore["AggregateExec (Partial / PartialMerge / Final)<br/>PhysicalGroupBy sees <b>LargeUtf8</b> keys<br/>→ dispatch ByteGroupValueBuilder&lt;i64&gt;<br/>buffer cap = i64::MAX (unbounded)"]
        Cast --> AggCore
    end

    subgraph Shuffle["CometExchange / CometNativeShuffleWriter"]
        direction TB
        Align["align_shuffle_writer_input<br/>promote expected_output_schema<br/><b>Utf8 → LargeUtf8</b> per <i>use_large_data_types</i>"]
        SchemaAlign["SchemaAlignExec<br/><b>passthrough</b> (no cast)"]
        IPC["Shuffle blocks encoded as <b>LargeUtf8</b>"]
        Align --> SchemaAlign --> IPC
    end

    subgraph JVMSide["JVM boundary"]
        direction TB
        Import["NativeUtil.importVector →<br/><b>LargeVarCharVector</b>"]
        TypeMap["Utils.fromArrowType<br/><b>LargeUtf8 → StringType</b>"]
        Import --> TypeMap
    end

    Downstream["Downstream CometHashAggregate<br/>re-promotion is a no-op<br/>(child schema already LargeUtf8)"]
    C2R["CometNativeColumnarToRow<br/>maybe_cast_to_schema_type:<br/><b>(LargeUtf8, Utf8) → passthrough</b><br/>TypedArray::LargeString → UnsafeRow"]
    Spark["Spark UnsafeRow (byte-oriented,<br/>offset width irrelevant)"]

    Scan --> Filter --> Agg --> Shuffle --> JVMSide --> Downstream --> C2R --> Spark

    style Cast fill:#fef3c7,stroke:#d97706
    style AggCore fill:#dbeafe,stroke:#2563eb
    style Align fill:#fef3c7,stroke:#d97706
    style SchemaAlign fill:#dcfce7,stroke:#16a34a
    style TypeMap fill:#fef3c7,stroke:#d97706
    style C2R fill:#dcfce7,stroke:#16a34a
Loading

Legend:

  • 🟨 amber = new/modified code path (this PR)
  • 🟦 blue = existing DF dispatch (already correct for LargeUtf8)
  • 🟩 green = passthrough / no-op (LargeUtf8 preserved)

@comphead
comphead marked this pull request as ready for review July 6, 2026 22:33
@mbutrovich

Copy link
Copy Markdown
Contributor

We should check with upstream datafusion. @alamb mentioned that the group by stuff is being rewritten to be more efficient and avoid this giant interim batch in the first place. I'd like to make sure if we tackle this in Comet it's a generalizable solution and not special-casing a hack. Also at a glance the comments seem huge and redundant.

"because the cap is only reachable for very large per-partition group cardinalities; " +
"enable it when you see the offset-overflow error.")
.booleanConf
.createWithDefault(true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says that this is an experimental feature and is disabled by default. Is it intentional that it is enabled by default here?

super.sparkConf.set(SQLConf.ANSI_ENABLED.key, "false")
super.sparkConf
.set(SQLConf.ANSI_ENABLED.key, "false")
.set("spark.memory.offHeap.enabled", "false")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes seem specific to the ignored test, but will impact all of the existing tests?

@comphead

comphead commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

@mbutrovich this is more like a hack right now to support aggregation explosion cases, as a more long term solution we can consider StringView usage later on, or if DataFusion GROUP BY being rewritten, then the hack can be removed.

// The test to reproduce `offset overflow` for aggregation queries, when interim data
// get exploded 100x comparing to initial input size.
// It is not supposed to run on CI as the test requires significant RAM to succeed
ignore("CUBE(9) + COUNT(DISTINCT) wide Utf8 keys: useLargeDataTypes preserves correctness") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any new tests for the functional changes in this PR. Could you add functional tests that use small amounts of data, just to test for correctness?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire CI is passed with useLargeDataTypes enabled.
on CI grade machines we cannot reproduce issue as we run out of memory earlier than hit the offset limit

@alamb

alamb commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

We should check with upstream datafusion. @alamb mentioned that the group by stuff is being rewritten to be more efficient and avoid this giant interim batch in the first place. I'd like to make sure if we tackle this in Comet it's a generalizable solution and not special-casing a hack. Also at a glance the comments seem huge and redundant.

FWIW we are working on refactoring aggregates here

Then I think we will be in position to update the allocation strategy internally (which currently uses large contiguous allocations for group values and aggregates

@alamb

alamb commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Support LargeUtf8/LargeBinary group keys in HashAggregate to bypass the 2 GiB offset cap

Once we support non contiguous allocations, I think we'll be in the position to avoid blowing out the offsets for more than 2GB of string data

@alamb

alamb commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

(to be clear I doubt we'll have non contiguous allocations for DF 55 - maybe 56)

@comphead

comphead commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

(to be clear I doubt we'll have non contiguous allocations for DF 55 - maybe 56)

Thanks @alamb for the input

@andygrove

Copy link
Copy Markdown
Member

Note on this review: this was generated by an LLM (Claude Code) at my request while I worked through a review backlog. I have not verified the individual findings myself. Please treat everything below as suggestions to evaluate rather than as authoritative review feedback, and push back on anything that is wrong or already handled.

The problem is real: ByteGroupValueBuilder<i32> hitting the 2 GiB cap on a high-cardinality CUBE with wide string keys is a genuine wall, and promoting the group keys to LargeUtf8 is the obvious way through it. But I have concerns about how broadly this reaches.

cast_array(LargeUtf8 -> Utf8) becomes a silent no-op

(DataType::LargeUtf8, DataType::Utf8) | (DataType::LargeBinary, DataType::Binary) => {
    Ok(Arc::clone(array))
}

This is in native/spark-expr/src/conversion_funcs/cast.rs, which every Comet cast goes through, not just the aggregate path. After this, asking for a Utf8 result can hand back a LargeUtf8 array. Any caller that trusts the returned type, and FFI export in particular, which matches against a declared schema, now has a type mismatch that will surface far from here.

The comment explains why the real cast is undesirable in the aggregate case (absolute offsets above i32::MAX even when the slice would fit). That is a good reason not to cast in that context, but it is not a reason for the general cast function to lie about its output type. Could the special case live in the aggregate coercion path rather than in cast_array? If it truly has to be here, it needs a loud comment and, ideally, an assertion at the FFI boundary that the exported type matches the declared one.

Defaulting the config to true

spark.comet.exec.useLargeDataTypes defaults to true, so every string and binary group key in every Comet aggregate gets i64 offsets. That doubles the offset buffer for those columns in all workloads, in order to fix an overflow that only bites at extreme cardinality.

What does that cost on a normal aggregate? A GROUP BY on a short string key with a few thousand groups would be the interesting measurement. If the cost is small the default is fine and the number should be in the description. If it is not, defaulting to false and documenting the config as the fix for the overflow seems better than taxing everyone.

Also, the description calls the config spark.comet.exec.useLargeDataTypes while a code comment calls it spark.comet.exec.aggregation.useLargeDataTypes. Worth settling on one.

Shuffle wire format changes

SchemaAlignExec writes Large* into shuffle blocks while Catalyst still declares the small variant, and ShuffleScanExec coerces back on read. Within one job that is consistent. What about a rolling upgrade, or blocks written by an executor running a different Comet version? If mixed versions are not supported that is fine, but it should be stated, because this is the second PR in flight changing a shuffle-visible Arrow type (#5292 does the same for calendar intervals).

Title and description

"fix: experiments with large types for aggregated values" is not a merge-ready title, and the body starts with an # heading inside the PR template rather than filling in the template's sections. Since the changelog is generated from titles, this would land as "experiments". Something like "fix: support LargeUtf8/LargeBinary group keys to bypass the 2 GiB offset cap" would read much better.

How was the fix validated?

The original repro needs more than 2 GiB of interned group keys, which is not something a unit test can do. CometAggregateSuite gains 114 lines, presumably testing the type plumbing rather than the overflow. Was the actual failing workload re-run with the fix? A note saying so, even without a reproducible test, would be worth having.

@alamb

alamb commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

FYI here is a ticket upstream in DataFusion that explains the root cause

@andygrove andygrove added enhancement New feature or request area:aggregation Hash aggregates, aggregate expressions labels Sep 6, 2026
@github-actions github-actions Bot added area:shuffle Shuffle (JVM and native) area:expressions Expression evaluation area:ffi Arrow FFI / JNI boundary labels Sep 24, 2026

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Prior state and problem: String/binary aggregation keys can overflow DataFusion’s 32-bit offsets once accumulated values exceed 2 GiB.
  • Design approach: Promote grouping expressions to LargeUtf8/LargeBinary, then narrow aggregate output through SchemaAlignExec.
  • Correctness / compatibility analysis: Found three introduced issues below. Bounded tests reproduce a final-aggregation spill failure for both types. Small-data null/empty-key cases pass. Checked relevant Spark sources across 3.4.3, 3.5.9, 4.0.4, 4.1.3 and 4.2 development.
  • Key design decisions: Keeping external schemas unchanged preserves Spark’s type contract, but final aggregation’s internal spill schema must also match promoted keys.
  • Implementation sketch: Scala serializes the new flag, the native planner adds promotion and output alignment, and conversion/shuffle/FFI helpers accept large-offset types.
  • Behavioral changes worth calling out: The feature defaults to enabled. Affected aggregates lose their reported metrics and copy all key bytes during narrowing, including ordinary batches below the overflow limit.
  • Suggested improvements: Align final-aggregate input with its spill schema, retain aggregate metrics through the wrapper, and use buffer-sharing casts for batches whose offsets fit.

Reviewed the entire diff from 64e98918ab11c41089416b18b262f56f83d41344 to 7db0b70d8f394e129dac613c39f3c2ab2cfd715d. The PR remains open and non-draft. Read existing discussion and threads. The previously discussed no-op cast is absent at this head.

Routed skills: review-comet-pr, review-comet-expression-pr, review-comet-ffi-pr, review-comet-memory-pr, and review-comet-shuffle-pr.

Exact-head CI: All non-skipped checks passed, including Linux Rust tests, Spark 4.1 Comet suites, and TPC-H/TPC-DS. Spark SQL, Iceberg, macOS, and benchmark checks were skipped. The added large-data test was ignored.

Validation: A disposable harness used the exact SchemaAlignExec source with locked DataFusion 55.1.0 and Arrow 59.3.0. Its three existing tests passed. Additional bounded cases verified results, reproduced spill failures and missing metrics, and confirmed a projection-based correction. An optimized microbenchmark measured narrowing costs. No full Comet JVM/native rebuild, full Spark SQL/Iceberg run, or >2 GiB reproduction was performed. Benchmark timings cover conversion only. Project files remain unchanged.

Comment thread native/core/src/execution/planner.rs Outdated
.map(|r| (r, format!("col_{idx}")))
let raw = self.create_expr(expr, Arc::clone(&child_schema_ref))?;
let (wrapped, revert) = if use_large {
promote_byte_group_key(raw, child_schema)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the final aggregate’s input and spill schemas consistent with promoted keys. This promotion also runs in Final mode, while its child still produces Utf8/Binary. DataFusion 55.1.0 uses agg.input().schema() as the final aggregate’s state schema. When memory pressure triggers spilling, it stamps that small-offset schema onto promoted group arrays and fails with expected Utf8 but found LargeUtf8 (likewise for binary). The output alignment cannot fix an error inside the aggregate. With the default enabled, ordinary spilling GROUP BY/DISTINCT queries now fail. Promote the final input through a matching projection, or otherwise make its spill state schema agree with the promoted keys.

Evidence: Bounded reproduction in /tmp/comet-4791-validation/src/main.rs: native Final distinct aggregation over 80 batches of 256 rows, 10,000 distinct 128-byte keys, batch size 256, and a 1 MiB FairSpillPool. Without promotion, both Utf8 and Binary return 10,000 keys after five spills. With the PR’s grouping-expression promotion, both fail before returning rows with the corresponding small/large type mismatch. Promoting the input through ProjectionExec restores 10,000 results and five spills. DataFusion’s aggregate_hash_table/final_table.rs takes the state schema from agg.input().schema(), and common.rs::take_state_batch constructs the failing batch.

})
.collect();
let target_schema: SchemaRef = Arc::new(Schema::new(target_fields));
SchemaAlignExec::try_new_or_passthrough(aggregate, &target_schema)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve aggregate metrics when installing this wrapper. SchemaAlignExec has no metrics() implementation, but it replaces AggregateExec as the native plan passed to SparkPlan::new. to_native_metric_node consequently reads no metrics and never visits the wrapped aggregate. Every promoted string/binary aggregation loses output-row, execution-time, and spill reporting, including the spill information propagated into Spark task metrics. Forward the aggregate’s metrics through the wrapper or explicitly retain it as the metric source.

Evidence: The exact-source harness executes string and binary aggregates containing duplicates, nulls, and empty keys. Both return four groups, and the underlying AggregateExec reports output_rows=4 and nonzero elapsed_compute, while the wrapper returns metrics=None. SparkPlan::new leaves additional_native_plans empty, and native/core/src/execution/metrics/utils.rs::to_native_metric_node only reads the root metrics in that case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm this end to end with a full build. SELECT k, sum(v) FROM t GROUP BY k on a string key shows the partial CometHashAggregateExec with output_rows=0 and elapsed_compute=0 when the flag is on, against 20000 rows and about 50 ms with it off. The final aggregate's elapsed_compute drops from about 10 ms to 17 µs. When fixing it, note that to_native_metric_node skips output_rows from additional_native_plans, so registering the AggregateExec through SparkPlan::new_with_additional also needs the wrapper to record its own output_rows. A test asserting these metrics on a string-keyed aggregate would catch this. The one in CometExecSuite uses a numeric key.

// the underlying Vec never has to grow-and-memcpy while we replay rows.
let offsets = arr.value_offsets();
let values_bytes = (offsets[arr.len()] - offsets[0]) as usize;
let mut builder = StringBuilder::with_capacity(arr.len(), values_bytes);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Avoid copying value buffers when the offsets already fit. This builder path runs for every promoted string key, with the equivalent copy for binary, even when no splitting or rebasing is needed. Since promotion defaults to enabled, each partial/final aggregation adds a full allocation and copy of its emitted key bytes. Arrow can narrow ordinary batches while sharing those bytes. Use that fast path when offsets fit, reserving rebuilding for overflow slices, or rebase offsets against a shared value-buffer slice.

Evidence: The exact SchemaAlignExec source copies a 4 MiB value buffer for 8,192 keys of 512 bytes, verified by buffer-pointer comparison. Arrow 59.3.0’s cast shares it and produces identical values. An optimized benchmark of the PR’s exact builder branch measured median conversion times of 168.1 µs versus 8.4 µs for that batch, and 7.81 ms versus 8.3 µs for 8,192 keys of 8 KiB. These are narrowing-only measurements, not whole-query timings. Reproduction: /tmp/comet-4791-cast-bench/src/main.rs.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the Large types inside the aggregate is a good call. Shuffle blocks and the FFI boundary stay Utf8, which settles the rolling-upgrade question from my earlier review, and the TPC-H and TPC-DS result checks pass with the flag on. The title and body still describe an earlier design, though. The body mentions spark.comet.exec.useLargeDataTypes, a ShuffleWriter.use_large_data_types flag, changes to align_shuffle_writer_input and CometNativeShuffleWriter.scala, and Large types carried through shuffle with no cast-back. None of that is in the diff. Could the body be rewritten for the current head, along with the title change I suggested earlier?

}

#[derive(Debug, Clone)]
enum ColumnAction {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the rebase brought back the ColumnAction enum that #5138 removed from this file. #5138 routed SchemaAlignStream through cast_and_stamp_schema, so every batch is checked against the target schema and a failed cast names the operator and the column path. Here the choice is made once at plan time. Passthrough columns are no longer checked per batch, and the Cast arm returns a bare arrow error. This operator sits in front of every native shuffle writer, so that applies with the flag off too. Could the Large-to-small columns be handled first, with the result then handed to cast_and_stamp_schema?

}
true
match (actual_field.data_type(), expected_field.data_type()) {
(DataType::LargeUtf8, DataType::Utf8) => ColumnAction::CastLargeStringToString,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the flag on, reusing SchemaAlignExec above the aggregate hits the warning just above and logs ShuffleWriter input schema mismatch on col[0] 'col_0': child produced LargeUtf8, catalyst declared Utf8. Inserting a cast; please file the upstream function bug at .../issues/4515 on each executor. I saw it in my local runs. There is no shuffle writer or upstream bug involved here, and the module doc says this operator is enclosed by shuffle on purpose. Would a small operator dedicated to the cast-back be cleaner? It could record its own metrics, which would also help with the metrics problem sunchao raised in planner.rs.

"because the cap is only reachable for very large per-partition group cardinalities; " +
"enable it when you see the offset-overflow error.")
.booleanConf
.createWithDefault(true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked in July whether defaulting this to true was intentional, and the doc string still says it defaults to false. I measured it on a release build. SELECT count(*), sum(s) FROM (SELECT k, sum(v) s FROM t GROUP BY k) over 8M rows with 4M distinct 21-byte keys took 494 ms at the median with the flag on and 450 ms with it off, about 10% slower over five alternating runs. Low-cardinality and 200-byte keys were within noise. My laptop was busy, so treat the numbers as rough. The overflow needs more than 2 GiB of distinct key bytes in one task, and apache/datafusion#24704 tracks the real fix. Could this default to false, with the tuning guide pointing at it for the offset overflow error? Either way the doc needs updating. It describes the cast-back as a Projection and says the overhead is O(rows), but as sunchao pointed out, the cast-back copies every key byte.

.createWithDefault(false)

val COMET_AGG_USE_LARGE_DATATYPES: ConfigEntry[Boolean] =
conf(s"$COMET_EXEC_CONFIG_PREFIX.aggregation.useLargeDataTypes")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other aggregate keys use spark.comet.exec.aggregate.*, and config_conventions.md asks for feature flags to end in .enabled. This key will be covered by the versioning policy once it ships, so could we settle on something like spark.comet.exec.aggregate.largeGroupKeys.enabled now?

// Native shuffle may dictionary-encode string/binary columns for efficiency,
// but downstream DataFusion operators expect the value types declared in the
// schema (e.g. Utf8, not Dictionary<Int32, Utf8>).
// Coerce each decoded column to the catalyst-declared type:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the aggregate now casts back before anything above it sees the batch, I don't think this feature can put LargeUtf8 into a shuffle block. If one did, the remote read path would reject it, because remote_schema.rs treats LargeUtf8 against Utf8 as an incompatible type. On the local path, ShuffleScanStream::poll_next already reconciles every column through cast_and_stamp_schema, so the extra cast here duplicates that. Could this go back to unpacking dictionaries only? Returning an error instead of the old expect is a good change and worth keeping.

valueVector match {
case v if isSupportedFieldVector(v) =>
v.asInstanceOf[FieldVector]
// Accepted here but left out of isSupportedFieldVector, which isArrowBacked uses to keep

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which path needs getFieldVector to accept LargeVarCharVector now that the aggregate casts back natively? As far as I can tell, the only Large vectors on the JVM side come from PyArrow UDFs returning large_string. So this changes native C2R export and broadcast serialization for that path without a test. It also makes the isSupportedFieldVector doc, the isArrowBacked comment and the UtilsSuite test comment wrong, since all three assume getFieldVector rejects these vectors. Could this be dropped here, or moved to its own PR with a PyArrow test? The same question applies to the LargeUtf8 passthrough at columnar_to_row.rs:1041.

matches!(to_type, DataType::Binary | DataType::LargeUtf8)
}

pub(crate) fn is_df_cast_from_large_string_spark_compatible(to_type: &DataType) -> bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find a way to reach these new arms, or the matching ones in cast.rs. types.proto has no Large type ids, so the serde never emits a Cast to or from one. The Parquet schema adapter's casts already go through the is_adapting_schema branch in cast_array. The comment also says SchemaAlignExec pre-splits arrays before they get here, but SchemaAlignExec builds its arrays directly and never calls this cast. Could these arms be removed from this PR?

// The test to reproduce `offset overflow` for aggregation queries, when interim data
// get exploded 100x comparing to initial input size.
// It is not supposed to run on CI as the test requires significant RAM to succeed
ignore("CUBE(9) + COUNT(DISTINCT) wide Utf8 keys: useLargeDataTypes preserves correctness") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is ignored, so CI never runs it. The sparkConf comment at line 60 still describes the off-heap and memory pool settings that 7252adb removed. This test now sets the pool through withSQLConf, which that comment says won't take effect. You mentioned in July that CI passes with the flag on. That covers the common path, but nothing exercises the split in compute_row_ranges or checks the metrics. Could the byte cap be a parameter so a Rust unit test can split a small batch? And could this test and the stale comment be replaced with small tests that run? If the default moves to false, those tests would also need to turn the flag on explicitly.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Prior state and problem: High-cardinality string/binary grouping can exceed DataFusion’s 2 GiB group-key buffer limit.
  • Design approach: Promote keys to LargeUtf8/LargeBinary inside aggregation, then narrow output through SchemaAlignExec.
  • Correctness / compatibility analysis: Small null, empty-key, and duplicate-key cases pass. The existing P1 spill failure remains reproducible for both types with 10,000 keys and a 1 MiB pool. Compared relevant Spark sources across 3.4.3, 3.5.9, 4.0.4, 4.1.3, and 4.2 development.
  • Key design decisions: Keeping external schemas unchanged preserves Spark’s type contract. Reusing the shuffle alignment wrapper introduces the already-reported metrics and abstraction concerns.
  • Implementation sketch: Scala serializes the configuration flag. The native planner promotes grouping expressions and aligns aggregate output. Shuffle, cast, and FFI helpers gain large-offset handling.
  • Behavioral changes worth calling out: Promotion defaults to enabled. Independently reproduced the existing missing-metrics concern and confirmed that narrowing copies a 4 MiB value buffer which Arrow’s ordinary cast shares.
  • Suggested improvements: Resolve the existing spill-schema, aggregate-metrics, and unnecessary-copy threads before merging. No additional introduced P1/P2 issues found within this review.

Reviewed the entire 11-file diff from 64e98918ab11c41089416b18b262f56f83d41344 to 7db0b70d8f394e129dac613c39f3c2ab2cfd715d, including surrounding code and existing discussion. The PR remains non-draft. Existing findings are not duplicated below.

Routed skills: review-comet-pr, review-comet-expression-pr, review-comet-ffi-pr, review-comet-memory-pr, and review-comet-shuffle-pr.

Exact-head CI: All executed checks passed, including Linux Rust tests, Spark 4.1 Comet suites, and TPC-H/TPC-DS result checks. Spark SQL, Iceberg, PyArrow UDF, macOS, and benchmark jobs were skipped.

Validation: Reran the disposable harness against the exact SchemaAlignExec source with DataFusion 55.1.0 and Arrow 59.3.0. Its 19 tests passed, including additional splitter tests using a reduced 32-byte cap to exercise multiple columns, sliced offsets, nulls, empty batches, and oversized-row rejection. No full Comet JVM/native rebuild, full Spark SQL/Iceberg run, or actual >2 GiB validation was performed. Project files and GitHub state were unchanged.

@comphead

Copy link
Copy Markdown
Contributor Author

Final aggregates fail on spill with useLargeDataTypes=true

A job running this branch (rebased on 64e98918ab, Spark 3.4) fails with:

org.apache.comet.CometNativeException: Invalid argument error: column types must match schema types, expected Binary but found LargeBinary at column index 0
    at org.apache.comet.Native.executePlan(Native Method)
    ...
    at org.apache.spark.sql.comet.execution.shuffle.CometNativeShuffleWriter.writeInternal(CometNativeShuffleWriter.scala:215)

Cause:

  • promote_byte_group_key casts every Utf8/Binary group-by expression to LargeUtf8/LargeBinary, in every aggregate mode.
  • DataFusion 55.1's final hash aggregation builds its spill batches against its input schema. In datafusion-physical-plan, AggregateHashTable<FinalMarker>::new passes agg.input().schema() as the state schema (src/aggregates/aggregate_hash_table/final_table.rs:46), and FinalSpillContext gets the input stream's schema (src/aggregates/hash_stream.rs:1038-1044). After a shuffle, that input still declares Binary.
  • When the final aggregate spills, take_state_batch stamps the LargeBinary group values with that Binary field (src/aggregates/aggregate_hash_table/common.rs:320). That is the error above. Only tasks that spill fail.
  • Partial aggregates are fine, because their state schema is derived from the group-by expressions (src/aggregates/aggregate_hash_table/partial_table.rs:50-55).
  • The CUBE test doesn't catch this. It is ignored and runs with an unbounded pool, so no final aggregate spills.

Suggested fix: for DataFusion Final mode, cast the key columns in a pass-through ProjectionExec below the aggregate, instead of casting the group-by expressions. The aggregate's input, its group values and its spill files then share one type. The aggregate expressions stay bound to the child schema, because the state columns pass through unchanged. Partial and PartialMerge keep promote_byte_group_key.

Two planner tests cover it:

  • final_aggregate_promotes_group_keys_at_its_input checks that the input to the final AggregateExec is the promoting projection and that its key type matches the group-by output.
  • final_aggregate_with_large_group_keys_survives_spill runs a final aggregate over 50k binary keys with a 1 MiB pool, and asserts that it spills and returns every key as Binary.

Both tests pass with the fix. With Final sent back through promote_byte_group_key, the spill test fails with exactly the error above, ArrowError(InvalidArgumentError("column types must match schema types, expected Binary but found LargeBinary at column index 0")), and the plan test fails too.

Workaround until then: spark.comet.exec.aggregation.useLargeDataTypes=false.

Two more things on this branch:

  • The config doc says the default is false, but the code uses createWithDefault(true).
  • When keys are promoted, the reverting SchemaAlignExec becomes the aggregate's native root. It has no metrics(), so the HashAggregate's native metrics, including spill counts, no longer reach Spark.

DataFusion's final hash aggregation spills against its input schema
(`AggregateHashTable<FinalMarker>` takes `agg.input().schema()`), so
casting a Final group-by expression to LargeUtf8/LargeBinary made the
first spill fail with "column types must match schema types, expected
Binary but found LargeBinary at column index 0".

For Final aggregates, cast the key columns in a pass-through projection
below the aggregate instead, so its input, group values and spill files
share one type. Partial and PartialMerge keep the expression cast.

Adds a plan-shape test and a spill test. Both fail with the original
error when Final aggregates take the expression cast.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:aggregation Hash aggregates, aggregate expressions area:expressions Expression evaluation area:ffi Arrow FFI / JNI boundary area:shuffle Shuffle (JVM and native) enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Comet aggregation task crashes with offset overflow

5 participants